第 7 章 Spring Boot 安全管理
写给新手的话:
前面我们学了怎么写接口、怎么操作数据库、怎么用缓存。但是有个问题:谁都能访问你的接口吗?
显然不行。用户要登录才能用,不同的用户有不同的权限,管理员能做的事普通用户不能做。
这就是安全管理要解决的问题:认证(你是谁)和 授权(你能做什么)。
Spring Boot 里最常用的安全框架就是 Spring Security,功能强大,跟 Spring 生态无缝集成。
学习建议:
- Spring Security 刚开始学可能觉得有点复杂,概念比较多
- 先把核心概念搞清楚:认证、授权、UserDetails、UserDetailsService
- 多动手,跟着例子敲代码,跑起来就明白了
- 不用一下子全学会,先掌握常用的,其他的用到再查
准备好了吗?我们开始吧!
7.1 先搞明白:安全管理那些事儿
7.1.1 为什么需要安全管理
想象一下:
- 你做了一个图书管理系统
- 任何人都能访问,不用登录
- 任何人都能删书、改价格
- 这能行吗?肯定不行!
所以我们需要安全管理,主要解决两个问题:
认证(Authentication):你是谁?
- 验证用户身份,比如用户名密码登录
- 确认你是这个系统的合法用户
授权(Authorization):你能做什么?
- 登录之后,你有哪些权限
- 普通用户只能看书,管理员才能改书
- 不同角色有不同的操作权限
简单记:
- 认证 = 证明你是谁(登录)
- 授权 = 决定你能干嘛(权限)
7.1.2 常见的安全框架
Java 生态里常见的安全框架:
| 框架 | 特点 | 适用场景 |
|---|---|---|
| Spring Security | Spring 官方,功能强大,生态好 | Spring 项目首选 |
| Apache Shiro | 轻量,简单,灵活 | 小项目、非 Spring 项目 |
| Sa-Token | 国产,简单易用,功能全 | 新项目可以试试 |
Spring Security 的优势:
- Spring 官方出品,跟 Spring Boot 无缝集成
- 功能非常全面:认证、授权、攻击防护、OAuth2、JWT 等
- 社区活跃,资料多
- 企业用得很多
Shiro 的优势:
- 轻量,简单,上手快
- 不依赖 Spring,什么项目都能用
- 概念少,容易理解
现在 Spring Boot 项目,一般都用 Spring Security。 这一章我们就学 Spring Security。
7.1.3 Spring Security 是什么
Spring Security 是 Spring 家族的安全框架,提供了一套完整的安全解决方案。
主要功能:
- 认证(用户名密码登录、手机号登录、第三方登录等)
- 授权(URL 权限、方法权限、数据权限)
- 密码加密
- 会话管理
- CSRF 防护
- 记住我(Remember Me)
- 退出登录
- OAuth2.0 / SSO
- 等等...
Spring Security 功能非常强大,也比较复杂。 这一章我们学最核心、最常用的部分:认证和授权。
7.1.4 Spring Security 的核心思想
Spring Security 的核心是过滤器链(Filter Chain)。
打个比方:
- 请求就像一个人要进一栋大楼
- 大楼门口有好几道关卡(过滤器)
- 每道关卡检查一样东西:有没有登录、有没有权限、有没有 CSRF token...
- 所有关卡都通过了,才能进到大楼里(访问你的接口)
- 哪一关没过,就被拦下来了
请求 → 过滤器1 → 过滤器2 → 过滤器3 → ... → Controller
(认证) (授权) (CSRF)Spring Security 就是通过一系列过滤器来实现安全控制的。
不用太深入原理,先知道有这么个东西就行。 后面用多了自然就理解了。
7.2 快速入门:第一个 Spring Security 项目
我们来动手搭一个最简单的 Spring Security 项目,感受一下。
7.2.1 创建项目
步骤 1:创建 Spring Boot 项目
选择依赖:
- Spring Web
- Spring Security
- Thymeleaf(后面做页面用)
pom.xml 关键依赖:
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>步骤 2:写一个测试接口
@Controller
public class HelloController {
@RequestMapping("/hello")
@ResponseBody
public String hello() {
return "Hello Spring Security!";
}
}步骤 3:启动项目
启动项目,你会在控制台看到一行类似这样的输出:
Using generated security password: 3e4d5f6g-7h8i-9j0k-1l2m-3n4o5p6q7r8s这是 Spring Security 自动生成的默认密码。
步骤 4:访问测试
浏览器访问 http://localhost:8080/hello
你会发现...自动跳转到了一个登录页面!
这就是 Spring Security 的默认效果:加了依赖,所有接口就自动被保护了。
默认用户:
- 用户名:
user - 密码:控制台输出的那串(每次启动都不一样)
输入用户名密码,登录成功,就能看到 Hello 了。
是不是很神奇?什么都没配置,就有了登录功能。 这就是 Spring Boot 的魅力:自动配置,开箱即用。
7.2.2 内存用户配置
默认的用户只有一个,密码还是随机的,肯定不够用。
我们来配置几个内存用户。
创建配置类:
@Configuration
public class SecurityConfig {
@Bean
public UserDetailsService userDetailsService() {
// 内存用户管理器
InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
// 创建用户 admin,密码 123456,角色 admin
users.createUser(User.withUsername("admin")
.password("{noop}123456") // {noop} 表示不加密
.roles("ADMIN")
.build());
// 创建用户 user,密码 123456,角色 user
users.createUser(User.withUsername("user")
.password("{noop}123456")
.roles("USER")
.build());
return users;
}
}说明:
{noop}表示密码不加密,明文存储。开发测试用,生产环境绝对不能这样!.roles("ADMIN")给用户设置角色- 角色会自动加上
ROLE_前缀,也就是ROLE_ADMIN
测试一下:
重启项目,用 admin/123456 登录,看看能不能成功。
内存用户适合开发测试,或者用户很少的场景。 实际项目肯定是从数据库查用户,后面会讲。
7.2.3 密码加密
刚才用了 {noop} 明文密码,这很不安全。
生产环境密码一定要加密存储,Spring Security 推荐用 BCrypt 加密。
配置密码加密器:
@Configuration
public class SecurityConfig {
/**
* 密码加密器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder encoder) {
InMemoryUserDetailsManager users = new InMemoryUserDetailsManager();
// 密码用 BCrypt 加密
users.createUser(User.withUsername("admin")
.password(encoder.encode("123456"))
.roles("ADMIN")
.build());
users.createUser(User.withUsername("user")
.password(encoder.encode("123456"))
.roles("USER")
.build());
return users;
}
}BCrypt 的特点:
- 同一个密码,每次加密结果都不一样(加盐了)
- 不可逆,不能解密
- 验证的时候,把明文加密后跟密文比对
为什么不用 MD5?
- MD5 可以被彩虹表破解,不安全
- BCrypt 专门为密码设计,慢哈希,抗暴力破解
- 现在推荐用 BCrypt 或者 Argon2
7.3 核心概念:搞懂这几个就够了
Spring Security 概念比较多,先把这几个核心的搞明白。
7.3.1 UserDetails:用户信息
UserDetails 是 Spring Security 定义的用户信息接口。
public interface UserDetails extends Serializable {
// 用户的权限列表
Collection<? extends GrantedAuthority> getAuthorities();
// 密码
String getPassword();
// 用户名
String getUsername();
// 账号是否过期
boolean isAccountNonExpired();
// 账号是否锁定
boolean isAccountNonLocked();
// 密码是否过期
boolean isCredentialsNonExpired();
// 是否启用
boolean isEnabled();
}简单说:UserDetails 就是 Spring Security 认识的"用户"对象。
你自己的用户表(User 实体),Spring Security 不认识。 你需要把你的用户信息,转换成 Spring Security 认识的 UserDetails。
就像翻译一样:把你的用户,翻译成 Spring Security 能看懂的格式。
7.3.2 UserDetailsService:加载用户
UserDetailsService 是加载用户信息的接口。
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}只有一个方法:根据用户名加载用户信息,返回 UserDetails。
工作流程:
- 用户输入用户名密码登录
- Spring Security 调用 UserDetailsService.loadUserByUsername(username)
- 拿到 UserDetails(里面有正确的密码和权限)
- 跟用户输入的密码比对
- 比对成功 → 认证通过;失败 → 认证失败
UserDetailsService 就是个"查用户的服务"。 你实现这个接口,告诉 Spring Security 怎么从你的系统里查用户。
7.3.3 PasswordEncoder:密码加密
PasswordEncoder 是密码加密接口。
public interface PasswordEncoder {
// 加密
String encode(CharSequence rawPassword);
// 比对
boolean matches(CharSequence rawPassword, String encodedPassword);
}常用实现:
BCryptPasswordEncoder:推荐,最常用NoOpPasswordEncoder:不加密,明文(测试用)Pbkdf2PasswordEncoder:PBKDF2 算法SCryptPasswordEncoder:SCrypt 算法
一般用 BCrypt 就行。
7.3.4 SecurityContextHolder:获取当前用户
登录成功后,用户信息存在哪里?
存在 SecurityContextHolder 里,这是一个线程级别的存储。
// 获取当前认证信息
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 获取用户详情
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
// 获取用户名
String username = userDetails.getUsername();
// 获取权限
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
// 是否已经认证
boolean isAuthenticated = authentication.isAuthenticated();记住这个:想拿当前登录用户,就从 SecurityContextHolder 里拿。
7.3.5 认证 vs 授权
再强调一下这两个概念,别搞混了:
| 概念 | 英文 | 回答的问题 | 例子 |
|---|---|---|---|
| 认证 | Authentication | 你是谁? | 用户名密码登录 |
| 授权 | Authorization | 你能做什么? | 管理员才能删数据 |
顺序:先认证,后授权。
- 先登录(认证),知道你是谁
- 再判断你有没有权限(授权)访问这个资源
别把这两个词搞混了,面试经常问。
7.4 自定义登录页面
默认的登录页面太丑了,实际项目肯定要自己做登录页。
7.4.1 写一个登录页面
在 resources/templates/ 下创建 login.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>登录</title>
<style>
body {
font-family: Arial, sans-serif;
background: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.login-box {
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
width: 300px;
}
h2 {
text-align: center;
color: #333;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
color: #666;
}
input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 10px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #45a049;
}
.error {
color: red;
text-align: center;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="login-box">
<h2>用户登录</h2>
<!-- 登录失败提示 -->
<div th:if="${param.error}" class="error">
用户名或密码错误
</div>
<!-- 退出提示 -->
<div th:if="${param.logout}" style="color: green; text-align: center; margin-bottom: 10px;">
您已退出登录
</div>
<form action="/doLogin" method="post">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" placeholder="请输入用户名">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" placeholder="请输入密码">
</div>
<button type="submit">登录</button>
</form>
</div>
</body>
</html>注意:
- 表单的 action 是
/doLogin(后面配置) - 用户名输入框 name 是
username - 密码输入框 name 是
password - 登录失败会带
?error参数 - 退出会带
?logout参数
7.4.2 配置自定义登录页
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 授权配置
.authorizeRequests()
.antMatchers("/loginview", "/css/**", "/js/**", "/img/**").permitAll() // 登录页和静态资源放行
.anyRequest().authenticated() // 其他都需要登录
.and()
// 登录配置
.formLogin()
.loginPage("/loginview") // 自定义登录页
.loginProcessingUrl("/doLogin") // 登录表单提交的地址
.usernameParameter("username") // 用户名参数名
.passwordParameter("password") // 密码参数名
.defaultSuccessUrl("/") // 登录成功后跳转的页面
//.successForwardUrl("/") // 登录成功后转发
//.failureUrl("/loginview?error") // 登录失败跳转
.and()
// 退出登录
.logout()
.logoutUrl("/logout") // 退出地址
.logoutSuccessUrl("/loginview?logout") // 退出成功跳转
.and()
// 禁用 CSRF(先禁用,后面讲)
.csrf().disable();
return http.build();
}
}配置视图控制器:
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
// 访问 /loginview 直接跳转到 login.html
registry.addViewController("/loginview").setViewName("login");
registry.addViewController("/").setViewName("main");
}
}常用登录配置项:
| 配置项 | 说明 |
|---|---|
loginPage | 登录页地址 |
loginProcessingUrl | 登录表单提交的地址 |
usernameParameter | 用户名参数名(默认 username) |
passwordParameter | 密码参数名(默认 password) |
defaultSuccessUrl | 登录成功后默认跳转的页面 |
successForwardUrl | 登录成功后转发地址 |
failureUrl | 登录失败跳转地址 |
successHandler | 登录成功处理器(自定义逻辑) |
failureHandler | 登录失败处理器(自定义逻辑) |
defaultSuccessUrlvssuccessForwardUrl:
- defaultSuccessUrl:重定向,如果你之前访问了某个页面被拦截了,登录成功后会回到那个页面
- successForwardUrl:转发,不管之前访问什么,登录成功后都去这个地址
一般用 defaultSuccessUrl 体验更好。
7.4.3 测试一下
启动项目,访问任意页面,会跳转到你自定义的登录页。
用 admin/123456 登录,看看效果。
7.5 从数据库查询用户(自定义 UserDetailsService)
内存用户只能测试用,实际项目用户都存在数据库里。
我们来实现从数据库查询用户。
7.5.1 准备数据库表
用户表:
CREATE TABLE `user` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL UNIQUE,
`password` VARCHAR(100) NOT NULL,
`valid` INT DEFAULT 1 COMMENT '是否有效 1-有效 0-无效'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;权限表:
CREATE TABLE `priv` (
`id` INT PRIMARY KEY AUTO_INCREMENT,
`authority` VARCHAR(50) NOT NULL COMMENT '权限标识'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;用户权限关联表:
CREATE TABLE `user_priv` (
`user_id` INT,
`priv_id` INT,
PRIMARY KEY (`user_id`, `priv_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;插入测试数据:
-- 密码都是 123456 的 BCrypt 加密值
INSERT INTO `user` (username, password, valid) VALUES
('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', 1),
('user', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5EH', 1);
INSERT INTO priv (authority) VALUES
('ROLE_ADMIN'),
('ROLE_USER'),
('book:list'),
('book:manage');
INSERT INTO user_priv (user_id, priv_id) VALUES
(1, 1), -- admin 有 ROLE_ADMIN
(1, 3), -- admin 有 book:list
(1, 4), -- admin 有 book:manage
(2, 2), -- user 有 ROLE_USER
(2, 3); -- user 有 book:list注意:权限如果是角色的话,要加
ROLE_前缀。 比如角色 ADMIN,权限标识就是ROLE_ADMIN。
7.5.2 加依赖
<!-- JDBC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>7.5.3 配置数据库
spring:
datasource:
url: jdbc:mysql://localhost:3306/chapter07?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver7.5.4 写实体类
UserDto(用户):
@Data
public class UserDto {
private Integer id;
private String username;
private String password;
private Integer valid; // 是否有效
}Privilege(权限):
@Data
public class Privilege {
private Integer id;
private String authority;
}7.5.5 写 DAO
@Repository
public class UserDao {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 根据用户名查询用户
*/
public UserDto getUserByUsername(String username) {
String sql = "SELECT * FROM user WHERE username = ?";
List<UserDto> list = jdbcTemplate.query(sql,
new BeanPropertyRowMapper<>(UserDto.class), username);
if (list != null && list.size() == 1) {
return list.get(0);
}
return null;
}
/**
* 根据用户ID查询权限
*/
public List<String> findPrivilegesByUserId(Integer userId) {
String sql = "SELECT p.authority " +
"FROM user u, priv p, user_priv up " +
"WHERE up.user_id = u.id AND up.priv_id = p.id AND u.id = ?";
List<Privilege> list = jdbcTemplate.query(sql,
new BeanPropertyRowMapper<>(Privilege.class), userId);
List<String> privileges = new ArrayList<>();
list.forEach(p -> privileges.add(p.getAuthority()));
return privileges;
}
}7.5.6 实现 UserDetailsService
这是最关键的一步!实现 UserDetailsService 接口,告诉 Spring Security 怎么查用户。
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserDao userDao;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 1. 根据用户名查询用户
UserDto userDto = userDao.getUserByUsername(username);
if (userDto == null) {
// 用户不存在,抛出异常
throw new UsernameNotFoundException("用户不存在:" + username);
}
// 2. 根据用户ID查询权限
List<String> privileges = userDao.findPrivilegesByUserId(userDto.getId());
// 3. 转换成 UserDetails
UserDetails userDetails = User.withUsername(userDto.getUsername())
.password(userDto.getPassword())
.authorities(privileges.toArray(new String[0])) // 权限列表
.disabled(userDto.getValid() != 1) // 是否禁用
.build();
return userDetails;
}
}就这么简单!
- 你实现
loadUserByUsername方法 - 传入用户名,你返回 UserDetails(里面有密码、权限等)
- Spring Security 自动帮你做密码比对、登录处理
这就是 Spring Security 的设计:
- 它不管你的用户存在哪里(数据库、Redis、LDAP...)
- 你只要告诉它怎么查,它就帮你搞定认证
7.5.7 配置使用自定义 UserDetailsService
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig {
@Autowired
private UserDetailsService userDetailsService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.userDetailsService(userDetailsService) // 使用自定义的 UserDetailsService
.authorizeRequests()
.antMatchers("/loginview", "/css/**", "/js/**", "/img/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/loginview")
.loginProcessingUrl("/doLogin")
.defaultSuccessUrl("/")
.and()
.logout()
.logoutSuccessUrl("/loginview?logout")
.and()
.csrf().disable();
return http.build();
}
}7.5.8 测试一下
启动项目,用数据库里的用户登录试试。
- admin / 123456 → 管理员
- user / 123456 → 普通用户
搞定!现在用户是从数据库查的了。 这是实际项目中最常用的方式。
7.6 授权控制:你能做什么
认证解决了"你是谁",现在来看授权:"你能做什么"。
Spring Security 支持两种级别的授权:
- URL 级别授权:哪些 URL 需要什么权限
- 方法级别授权:哪些方法需要什么权限
7.6.1 URL 级别授权
在 SecurityConfig 里配置,哪些 URL 需要什么权限。
http.authorizeRequests()
// 所有人都能访问
.antMatchers("/loginview", "/css/**", "/js/**").permitAll()
// 需要 ADMIN 角色才能访问
.antMatchers("/admin/**").hasRole("ADMIN")
// 需要 book:manage 权限才能访问
.antMatchers("/book/manage/**").hasAuthority("book:manage")
// 需要同时满足多个权限
.antMatchers("/super/**").hasAnyAuthority("a", "b", "c")
// 其他的,登录就能访问
.anyRequest().authenticated();常用权限控制方法:
| 方法 | 说明 |
|---|---|
permitAll() | 所有人都能访问 |
denyAll() | 所有人都不能访问 |
authenticated() | 登录后就能访问 |
anonymous() | 匿名(未登录)才能访问 |
rememberMe() | 记住我登录的能访问 |
fullyAuthenticated() | 完全认证(不是记住我)才能访问 |
hasRole("ADMIN") | 有指定角色 |
hasAnyRole("ADMIN", "USER") | 有其中一个角色 |
hasAuthority("book:list") | 有指定权限 |
hasAnyAuthority("a", "b") | 有其中一个权限 |
hasIpAddress("192.168.1.0/24") | 指定 IP 才能访问 |
角色 vs 权限:
- 角色:
hasRole("ADMIN")→ 实际检查的是ROLE_ADMIN - 权限:
hasAuthority("book:list")→ 检查的就是book:list
简单理解:
- 角色是粗粒度的:管理员、普通用户
- 权限是细粒度的:新增、删除、修改
- 角色本质上也是一种权限,只是加了 ROLE_ 前缀
antMatchers 的匹配规则:
?匹配一个字符*匹配 0 个或多个字符**匹配 0 个或多个目录
例子:
/admin/**→ 匹配 /admin/ 下的所有路径/book/*.html→ 匹配 /book/ 下的所有 html 文件/book/?→ 匹配 /book/a、/book/b 等
注意:匹配顺序是从上到下的,前面的先匹配。 所以要把具体的放前面,通用的放后面。
7.6.2 方法级别授权
URL 级别授权是在配置里统一配的。 有时候你想在具体的方法上加权限控制,就用方法级别授权。
第一步:开启方法级安全
@Configuration
@EnableGlobalMethodSecurity(
securedEnabled = true, // 开启 @Secured
prePostEnabled = true, // 开启 @PreAuthorize / @PostAuthorize
jsr250Enabled = true // 开启 @RolesAllowed
)
public class SecurityConfig {
// ...
}第二步:在方法上加注解
@Secured:
@Controller
@RequestMapping("/book")
public class BookController {
// 所有人登录后都能访问
@RequestMapping("/list")
public String list() {
return "book_list";
}
// 需要 ROLE_ADMIN 角色才能访问
@RequestMapping("/admin/manag")
@Secured("ROLE_ADMIN")
public String manag() {
return "book_manag";
}
}@Secured 是 Spring Security 自带的注解。 注意:角色要写完整的
ROLE_ADMIN,不能只写 ADMIN。
@PreAuthorize:
// 需要 admin 角色
@PreAuthorize("hasRole('ADMIN')")
public void delete() { ... }
// 需要 book:manage 权限
@PreAuthorize("hasAuthority('book:manage')")
public void add() { ... }
// 满足任意一个
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
public void update() { ... }
// 可以用 SpEL,更灵活
@PreAuthorize("#userId == authentication.principal.id or hasRole('ADMIN')")
public void getUser(Long userId) { ... }@PreAuthorize 更强大,支持 SpEL 表达式。 角色不用写 ROLE_ 前缀,hasRole 会自动加。
@RolesAllowed:
@RolesAllowed("ROLE_ADMIN")
public void delete() { ... }@RolesAllowed 是 JSR-250 标准的注解,跟 @Secured 差不多。
三种注解对比:
| 注解 | 来源 | 支持 SpEL | 角色前缀 |
|---|---|---|---|
@Secured | Spring Security | 不支持 | 要写 ROLE_ |
@PreAuthorize | Spring Security | 支持 | hasRole 自动加 |
@RolesAllowed | JSR-250 标准 | 不支持 | 要写 ROLE_ |
一般推荐用 @PreAuthorize,因为更灵活,支持 SpEL。
7.6.3 权限不足怎么办
权限不足的时候,默认会返回 403 错误页面,很丑。
我们可以自定义 403 页面。
配置:
http.exceptionHandling()
.accessDeniedPage("/403"); // 权限不足跳转的页面写一个 403 页面:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>403 权限不足</title>
</head>
<body>
<h1>403 - 权限不足</h1>
<p>抱歉,您没有权限访问此页面</p>
<a href="/">返回首页</a>
</body>
</html>7.7 动态菜单展示:根据权限显示不同内容
登录之后,不同角色的用户看到的菜单应该不一样。 比如管理员能看到"管理后台"菜单,普通用户看不到。
用 Thymeleaf + Spring Security 标签可以很方便地实现。
7.7.1 加依赖
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>7.7.2 常用标签
在页面上引入命名空间:
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">常用属性:
| 属性 | 作用 |
|---|---|
sec:authorize="isAuthenticated()" | 已登录才显示 |
sec:authorize="isAnonymous()" | 未登录才显示 |
sec:authorize="hasRole('ADMIN')" | 有指定角色才显示 |
sec:authorize="hasAuthority('book:manage')" | 有指定权限才显示 |
sec:authentication="name" | 显示用户名 |
sec:authentication="principal.username" | 显示用户名 |
sec:authentication="authorities" | 显示权限列表 |
7.7.3 例子:动态菜单
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<head>
<meta charset="UTF-8">
<title>首页</title>
</head>
<body>
<h1>图书管理系统</h1>
<!-- 已登录才显示 -->
<div sec:authorize="isAuthenticated()">
<p>欢迎您,<span sec:authentication="name"></span>!</p>
<ul>
<!-- 所有人都能看到 -->
<li><a href="/book/list">图书列表</a></li>
<!-- 只有管理员能看到 -->
<li sec:authorize="hasRole('ADMIN')">
<a href="/book/admin/manag">图书管理</a>
</li>
</ul>
<a href="/logout">退出登录</a>
</div>
<!-- 未登录才显示 -->
<div sec:authorize="isAnonymous()">
<p>您还未登录</p>
<a href="/loginview">去登录</a>
</div>
</body>
</html>这样就实现了动态菜单:不同权限的用户看到的内容不一样。 非常方便!
7.8 会话管理
7.8.1 Session 超时
设置 Session 过期时间:
server:
servlet:
session:
timeout: 30m # 30 分钟超时超时后,用户需要重新登录。
7.8.2 并发会话控制
限制同一个用户最多能在几个地方登录:
http.sessionManagement()
.maximumSessions(1) // 最多 1 个会话(后登录的把前面的挤掉)
.maxSessionsPreventsLogin(false) // false=后登录挤掉前一个,true=后登录被禁止
.expiredUrl("/loginview?expired"); // 会话过期跳转两种策略:
maxSessionsPreventsLogin(false):后登录的把前面的挤掉(默认)maxSessionsPreventsLogin(true):已经登录了就不让再登录了
比如 QQ 就是后登录挤掉前面的,很多系统都是这样。
7.8.3 Session 固定攻击防护
什么是 Session 固定攻击?
- 攻击者先访问网站,拿到一个 SessionID
- 然后诱导你用这个 SessionID 登录
- 你登录后,这个 SessionID 就变成已登录的了
- 攻击者用同一个 SessionID 就能以你的身份访问
Spring Security 默认开启了 Session 固定攻击防护:登录成功后会换一个 SessionID。
http.sessionManagement()
.sessionFixation().migrateSession(); // 默认,迁移 Session几种策略:
migrateSession:迁移(默认,推荐)newSession:新建一个全新的 Sessionnone:不防护(不推荐)
默认的就行,一般不用改。
7.9 退出登录
退出登录很简单,Spring Security 已经帮你做好了。
7.9.1 基本配置
http.logout()
.logoutUrl("/logout") // 退出地址
.logoutSuccessUrl("/loginview?logout") // 退出成功跳转
.invalidateHttpSession(true) // 使 Session 失效(默认 true)
.clearAuthentication(true); // 清除认证信息(默认 true)7.9.2 页面上放退出链接
<a href="/logout">退出登录</a>点击就退出了,非常简单。
注意:如果开启了 CSRF,退出必须是 POST 请求。 禁用 CSRF 的话 GET 也行。 后面讲 CSRF 的时候会细说。
7.10 CSRF 防护
7.10.1 什么是 CSRF
CSRF(Cross-Site Request Forgery)跨站请求伪造。
简单说:
- 你登录了 A 网站,Cookie 里有 SessionID
- 你又访问了恶意网站 B
- 网站 B 偷偷发请求给 A 网站(比如转账、改密码)
- 因为你的浏览器带着 A 网站的 Cookie,A 网站以为是你在操作
- 于是就中招了
7.10.2 Spring Security 的 CSRF 防护
Spring Security 默认开启了 CSRF 防护。
原理:
- 每个表单都带一个 CSRF Token
- 提交的时候验证 Token
- Token 是随机的,攻击者拿不到
Thymeleaf 里自动加 CSRF Token:
Thymeleaf 的 form 标签会自动加上 CSRF Token,不用你管。
<form action="/doLogin" method="post">
<!-- 自动会加一个隐藏的 _csrf 字段 -->
<input type="text" name="username">
<input type="password" name="password">
<button type="submit">登录</button>
</form>7.10.3 什么时候禁用 CSRF
前后端分离的项目,一般会禁用 CSRF:
- 前后端分离用 Token(JWT)认证,不用 Cookie
- CSRF 攻击的前提是 Cookie 自动携带
- 不用 Cookie 的话,CSRF 就不存在了
禁用方式:
http.csrf().disable();我们前面的例子都禁用了 CSRF,是为了方便测试。 传统的服务端渲染项目(Thymeleaf 这种),建议开启 CSRF。 前后端分离项目,一般用 JWT,可以禁用。
7.11 记住我(Remember Me)
用户勾选"记住我",关闭浏览器再打开,还是登录状态。
7.11.1 简单版(内存)
http.rememberMe()
.rememberMeParameter("remember-me") // 表单参数名
.tokenValiditySeconds(7 * 24 * 60 * 60); // 有效期 7 天表单上加一个复选框:
<input type="checkbox" name="remember-me"> 记住我简单版的 Token 存在内存里,重启就没了。 适合开发测试。
7.11.2 持久化版(数据库)
生产环境用持久化的,Token 存在数据库里。
建表:
CREATE TABLE persistent_logins (
username VARCHAR(64) NOT NULL,
series VARCHAR(64) PRIMARY KEY,
token VARCHAR(64) NOT NULL,
last_used TIMESTAMP NOT NULL
);配置:
@Autowired
private DataSource dataSource;
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
// tokenRepository.setCreateTableOnStartup(true); // 自动建表(第一次用)
return tokenRepository;
}
http.rememberMe()
.tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(7 * 24 * 60 * 60);记住我功能很实用,用户体验好。 但要注意安全,敏感操作还是要重新验证密码。
7.12 获取当前登录用户
很多时候我们需要获取当前登录用户的信息。
7.12.1 从 SecurityContextHolder 获取
@RestController
public class UserController {
@RequestMapping("/getUsername")
public String getUsername() {
// 获取认证信息
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
if (!authentication.isAuthenticated()) {
return "未登录";
}
// 获取用户详情
UserDetails userDetails =
(UserDetails) authentication.getPrincipal();
return userDetails.getUsername();
}
}7.12.2 方法参数注入
@RequestMapping("/info")
public String info(Authentication authentication) {
return authentication.getName();
}
// 或者
@RequestMapping("/info2")
public String info2(@AuthenticationPrincipal UserDetails userDetails) {
return userDetails.getUsername();
}直接在方法参数上加,Spring 自动注入。
7.12.3 Thymeleaf 页面上获取
前面讲过,用 sec 标签:
<span sec:authentication="name"></span>7.13 企业最佳实践
7.13.1 密码安全
- 一定要加密:用 BCrypt,不要明文,不要用 MD5
- 密码强度:要求用户设置复杂密码
- 加密强度:BCrypt 的 strength 可以调,默认 10 就够了
- 定期改密码:敏感系统要求定期修改密码
7.13.2 权限设计
RBAC 模型(Role-Based Access Control):
用户 → 角色 → 权限- 用户有角色(比如:管理员、普通用户)
- 角色有权限(比如:新增、删除、修改)
- 用户通过角色获得权限
这是最常用的权限设计模型。
更复杂的还有:
- 数据权限(只能看自己的数据)
- 部门权限
- 动态权限(权限存在数据库,动态配置)
刚开始学,先把 RBAC 搞明白就够了。
7.13.3 前后端分离的认证
传统项目用 Session + Cookie。 前后端分离项目,一般用 JWT(JSON Web Token)。
JWT 的流程:
- 用户登录,成功后返回一个 Token
- 前端把 Token 存起来(localStorage)
- 每次请求都带上 Token(放在 Header 里)
- 后端验证 Token
好处:
- 无状态,服务端不用存 Session
- 分布式系统友好
- 支持跨域
JWT 现在非常流行,前后端分离项目基本都用。 Spring Security 也支持 JWT,但需要自己配置一下。 这一章没讲,有兴趣可以自己去了解。
7.13.4 安全防护
- CSRF 防护:传统项目要开,前后端分离用 JWT 可以关
- XSS 防护:前端转义,后端也要校验
- SQL 注入:用参数化查询,不要拼接 SQL
- 暴力破解防护:登录失败次数限制、验证码
- HTTPS:生产环境一定要用 HTTPS
7.13.5 日志与审计
- 记录登录日志(谁、什么时候、从哪登录的)
- 记录敏感操作日志(谁、什么时候、做了什么)
- 出问题能追溯
7.14 新手常见问题排查
问题 1:加了 Spring Security 依赖,所有页面都要登录
原因:Spring Security 默认保护所有 URL
解决:在配置里配置哪些 URL 不需要登录(permitAll)
问题 2:登录一直失败,密码明明是对的
可能原因:
- 密码没加密,或者加密方式不对
- UserDetailsService 没配置对
- 用户名参数名不对
- 密码格式不对
排查:
- 检查 PasswordEncoder 配置
- 检查数据库里的密码是不是 BCrypt 加密的
- 打印一下 UserDetailsService 返回的用户信息
- 检查表单的 name 属性是不是 username/password
问题 3:权限不够,403 错误
可能原因:
- 角色没加 ROLE_ 前缀
- 权限标识写错了
- @Secured 里没写 ROLE_
- 方法级安全没开启
排查:
- 检查数据库里的权限是不是 ROLE_ 开头
- 检查 @EnableGlobalMethodSecurity 有没有开
- @Secured 要写完整的 ROLE_ADMIN
- @PreAuthorize 的 hasRole 不用写 ROLE_
问题 4:自定义登录页样式不对
原因:静态资源被拦截了
解决:把静态资源路径加入 permitAll
.antMatchers("/css/**", "/js/**", "/img/**").permitAll()问题 5:退出登录不好使
可能原因:
- CSRF 开启了,退出用的 GET 请求
- 退出地址不对
解决:
- 禁用 CSRF,或者退出用 POST 请求
- 检查 logoutUrl 配置
问题 6:获取不到当前用户
可能原因:
- 没登录
- 类型转换错了
- SecurityContext 没拿到
排查:
- 先判断 isAuthenticated()
- 确认 principal 的类型(一般是 UserDetails)
- 检查是不是在异步线程里(异步线程拿不到,需要特殊处理)
7.15 本章小结
恭喜你!Spring Security 这一章学完了!
这一章内容比较多,概念也比较多,刚开始可能觉得有点乱。 没关系,先把核心的搞明白,其他的用到再查。
你都学了什么
安全管理概述:
- 为什么需要安全管理
- 认证 vs 授权
- 常见安全框架
- Spring Security 简介
快速入门:
- 引入依赖后的默认效果
- 内存用户配置
- 密码加密(BCrypt)
核心概念:
- UserDetails:用户信息
- UserDetailsService:加载用户
- PasswordEncoder:密码加密
- SecurityContextHolder:获取当前用户
自定义登录:
- 自定义登录页面
- 登录配置详解
- 自定义 UserDetailsService(从数据库查用户)
授权控制:
- URL 级别授权(antMatchers)
- 方法级别授权(@Secured、@PreAuthorize、@RolesAllowed)
- 权限不足处理
动态菜单:
- Thymeleaf Spring Security 标签
- 根据权限显示不同内容
会话管理:
- Session 超时
- 并发会话控制
- Session 固定攻击防护
其他功能:
- 退出登录
- CSRF 防护
- 记住我(Remember Me)
- 获取当前登录用户
企业最佳实践:
- 密码安全
- RBAC 权限模型
- 前后端分离与 JWT
- 安全防护
- 日志审计
动手实践清单
| 实践项 | 做完打勾 |
|---|---|
| 能搭建 Spring Security 项目,看到默认登录页 | ☐ |
| 会配置内存用户 | ☐ |
| 会配置密码加密(BCrypt) | ☐ |
| 能自定义登录页面 | ☐ |
| 能实现自定义 UserDetailsService(从数据库查) | ☐ |
| 会配置 URL 级别授权 | ☐ |
| 会用方法级别授权(@Secured / @PreAuthorize) | ☐ |
| 能实现动态菜单(Thymeleaf sec 标签) | ☐ |
| 会配置退出登录 | ☐ |
| 知道什么是 CSRF,什么时候开什么时候关 | ☐ |
| 会配置记住我 | ☐ |
| 能获取当前登录用户信息 | ☐ |
| 理解认证和授权的区别 | ☐ |
| 知道 RBAC 权限模型 | ☐ |
面试常问
Spring Security 的认证流程?
- 用户提交用户名密码
- UsernamePasswordAuthenticationFilter 拦截
- 调用 UserDetailsService 加载用户
- PasswordEncoder 比对密码
- 认证成功,把用户信息放到 SecurityContext
- 认证失败,跳转到失败页
Spring Security 的核心过滤器有哪些?
- UsernamePasswordAuthenticationFilter:用户名密码认证
- FilterSecurityInterceptor:授权过滤
- CsrfFilter:CSRF 防护
- RememberMeAuthenticationFilter:记住我
- LogoutFilter:退出
- ...还有很多
UserDetailsService 和 UserDetails 的区别?
- UserDetails:用户信息接口,定义了用户的属性(用户名、密码、权限等)
- UserDetailsService:加载用户的接口,根据用户名查询用户,返回 UserDetails
Spring Security 怎么实现授权?
- URL 级别:在配置里用 antMatchers 配置
- 方法级别:用 @Secured、@PreAuthorize 等注解
- 底层是通过 FilterSecurityInterceptor 实现的
BCrypt 加密的原理?为什么安全?
- 加盐:每个密码都有随机盐
- 慢哈希:计算慢,抗暴力破解
- 不可逆:不能解密
- 同一个密码每次加密结果不一样
什么是 CSRF?怎么防护?
- 跨站请求伪造,利用 Cookie 自动携带的特性
- 防护:CSRF Token,每次请求带 Token 验证
- 前后端分离用 JWT 的话,可以不用 CSRF
Session 共享怎么实现?
- Spring Session + Redis
- 把 Session 存在 Redis 里,多个实例共享
- 分布式系统必备
JWT 了解吗?和 Session 有什么区别?
- JWT 是无状态的,服务端不用存
- Session 是有状态的,服务端要存
- JWT 适合前后端分离、分布式系统
- JWT 缺点:不能主动失效,占带宽
给实习生的建议
先把核心的搞明白:认证、授权、UserDetailsService 这几个是基础,一定要搞懂。
多动手:Spring Security 光看没用,一定要自己写代码、跑起来、调通。
不用追求全都会:Spring Security 功能非常多,不可能一下子全学会。先掌握常用的,其他的用到再查。
理解设计思想:Spring Security 的设计很优秀,理解了它的设计思想,学其他安全框架也快。
安全无小事:生产环境的安全配置一定要谨慎,密码要加密、敏感操作要验证、漏洞要及时修。
前后端分离要学 JWT:现在很多项目都是前后端分离,JWT 是标配,有时间一定要学。
下一章我们学习消息服务(RabbitMQ)。加油!